測試回報很具體:「開著儀表板去吃午餐,回來點一下重新整理,畫面閃了好幾次,最後跳到登入頁,登入完又跳一次。」
我照著做了一遍。打開 Console,裡面是一整排紅字:
BrowserAuthError: interaction_in_progress
BrowserAuthError: interaction_in_progress
BrowserAuthError: interaction_in_progress
BrowserAuthError: interaction_in_progress
往回追,事情是這樣發生的。
儀表板一打開,會同時發出五個請求:檔案清單、空間用量、最近開啟、通知數量、使用者設定。剛好在這個時間點,後端判定這張 token 已經不能用了——可能是時間差、可能是權限剛被調整過,前端的 MSAL 還以為它有效。
五個請求同時回來 401。
攔截器寫的是「遇到 401 就 loginRedirect」。於是五個請求,各自呼叫了一次 loginRedirect。第一個成功開始了登入流程,剩下四個撞上「已經有一個登入在進行中了」,噴出那一排錯誤。
更糟的是,TanStack Query 看到請求失敗,預設會重試三次。每一次重試又是 401、又是一次 loginRedirect。
一個 token 失效,觸發了將近二十次登入。
講過競態:兩個請求誰先回來,是網路說了算,晚到的會蓋掉早到的。
今天這個是同一家族的問題,只是換了一個戰場:五個請求同時失敗,每一個都獨立決定「我來處理」。 沒有任何一個人知道其他人也正在做同一件事。
真正需要的行為很簡單:
第一個 401 回來 → 去換一張新 token
第二個 401 回來 → 發現已經有人在換了,等它換完
第三、四、五個 → 同上
新 token 拿到了 → 五個請求一起用新 token 重送
只換一次,大家共用結果。
在 Angular 寫過 token 刷新的人,應該對這個模式不陌生:
let refreshing$: Observable<string> | null = null;
export const refreshInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
return next(req).pipe(
catchError(error => {
if (error.status !== 401) return throwError(() => error);
refreshing$ ??= auth.refreshToken().pipe(
finalize(() => (refreshing$ = null)),
shareReplay(1),
);
return refreshing$.pipe(
switchMap(token => next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }))),
);
}),
);
};
關鍵是那個 shareReplay(1)。Day 13 介紹過它:多個訂閱者共用同一個執行中的 Observable,只會真的執行一次。第一個 401 建立了 refreshing$,後面四個訂閱的都是同一個。
我花了不少時間才把這段寫對——什麼時候要 share、什麼時候要清掉、switchMap 會不會又多觸發一次。
到了 React,我以為又要重新刻一套。結果答案簡單到讓我愣住。
把 Promise 比喻成一張收據:一建立就出發,未來會有一個結果。
收據有一個特性我當時沒提:同一張收據,可以給很多人看。 一個 Promise 可以被 await 很多次,每個人拿到的都是同一個結果,而裡面的工作只會執行一次。
這正好就是「只換一次,大家共用」:
// lib/apiClient.ts
let refreshPromise: Promise<string> | null = null;
function refreshTokenOnce(): Promise<string> {
if (!refreshPromise) {
refreshPromise = msalInstance
.acquireTokenSilent({
scopes: [import.meta.env.VITE_API_SCOPE],
account: msalInstance.getActiveAccount()!,
forceRefresh: true, // 不要用快取,一定要換一張新的
})
.then(result => result.accessToken)
.finally(() => {
refreshPromise = null; // 換完了,下次需要時再重新開始
});
}
return refreshPromise;
}
第一個呼叫的人,建立了這張收據。之後的人進來,發現收據已經存在,直接拿同一張回去等。換完之後清掉,下一次 token 又失效時,才會重新建立一張。
跑一次看看:
請求 A 回 401 → refreshPromise 是 null → 建立,開始換 token
請求 B 回 401 → refreshPromise 已存在 → 拿同一個,等
請求 C、D、E → 同上
新 token 回來 → A、B、C、D、E 同時拿到同一張新 token
finally → refreshPromise 清回 null
不需要 shareReplay、不需要 switchMap、不需要任何運算子。一個變數,加上 Promise 本來就會的事。
之前說 Observable 是藍圖、Promise 是收據,那時是在講 Promise 的限制。今天第一次覺得,收據也有收據的好處。
接到回應攔截器上:
apiClient.interceptors.response.use(undefined, async error => {
const config = error.config;
if (error.response?.status !== 401 || config._retried) {
return Promise.reject(error);
}
config._retried = true; // 標記:這個請求已經重送過一次了
try {
const token = await refreshTokenOnce();
config.headers.Authorization = `Bearer ${token}`;
return apiClient(config); // 用新 token 重送
} catch {
redirectToLoginOnce();
return Promise.reject(error);
}
});
兩個細節都是用來防止無限循環的:
_retried 標記。 每個請求只重送一次。如果用新 token 重送之後還是 401,代表問題不在 token 過期,而是這個人真的沒有權限,或是後端出了別的狀況。這時候就不該再換了,讓錯誤往上丟。
換不到 token 時,只導頁一次。 如果 acquireTokenSilent 本身也失敗了(例如登入狀態整個失效),才需要真的重新登入:
let redirecting = false;
function redirectToLoginOnce() {
if (redirecting) return;
redirecting = true;
msalInstance.loginRedirect({ scopes: [import.meta.env.VITE_API_SCOPE] });
}
五個請求同時走到這裡,也只會有一個真的觸發登入。那排 interaction_in_progress 消失了。
寫完我自己愣了一下。refreshPromise、redirecting,這兩個都是寫在模組層級的可變變數。
這兩個變數跟使用者有關嗎?有。refreshPromise 裝的就是某個使用者的 token。
它們之所以安全,是因為我們在選了 SPA 模式:所有程式碼只在瀏覽器裡執行,一個分頁只有一個使用者。如果哪天改成 SSR,這兩個變數就會變成整台伺服器共用,A 的請求可能等到 B 換來的 token。
跟使用者有關的狀態,只有在「確定只在瀏覽器執行」的程式碼裡,才能放在模組層級。
同時在 refreshPromise 上面加了一行註解,寫明它依賴 SPA 模式。規則不是死的,但例外要寫清楚理由。
修完攔截器,還剩一個問題:TanStack Query 的自動重試。
攔截器已經處理了 401 的重送,如果 TanStack Query 又照樣重試三次,401 的情況下等於是重複做一樣的事。更普遍地說,4xx 的錯誤——找不到、沒權限、參數錯誤——重試再多次結果都一樣。
所以把重試規則改成:只有伺服器錯誤或網路問題才重試。
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error) => {
if (error instanceof ApiError && error.status >= 400 && error.status < 500) {
return false; // 4xx 不重試
}
return failureCount < 3;
},
},
},
});
一個原則:每一種失敗,只讓一層負責重試。 401 由攔截器負責,5xx 跟斷線由 TanStack Query 負責,4xx 誰都不重試。
最後要誠實補一句,避免讀者以為這一切都得自己做。
正常情況下的 token 過期,MSAL 其實已經處理好了。acquireTokenSilent 在 token 快過期時會自動在背景換新的,新版的 MSAL 也會把同一時間、相同參數的多個 silent 請求合併成一次。所以 Day 24 的請求攔截器每次都呼叫 acquireTokenSilent,並不會因此多打很多次 Azure。
今天處理的,是 MSAL 看不到的那種情況:前端以為 token 還有效,後端卻拒絕了它。 這時候只有回應攔截器知道發生了 401,也只有它能決定要不要強制換一張。
如果你的系統不是用 MSAL,而是後端自己發的 JWT 加 refresh token,第三節那個「只換一次、大家共用」的寫法完全一樣,只是把 acquireTokenSilent 換成呼叫你自己的 refresh API。
| Angular | React + axios | |
|---|---|---|
| 共用一次刷新 | shareReplay(1) |
把 Promise 存起來 |
| 刷新後重送 | switchMap 接 next(req.clone()) |
return apiClient(config) |
| 避免無限重送 | 自訂 header 或 context | config._retried 標記 |
| 避免重複導頁 | 旗標或 service 裡的狀態 | 模組層級旗標(僅限 SPA) |
| 上層重試 | 看各自的 retry 運算子 |
TanStack Query 的 retry 函式 |
一個 token 失效,觸發了將近二十次登入。原因不是某一行程式碼寫錯,而是五個同時發生的失敗,各自獨立地決定「我來處理」。
競態換了一個戰場。解法的核心也一樣:讓大家共用同一件事的結果,而不是各自重做一次。
在 Angular,這件事要靠 shareReplay 讓多個訂閱者共用一個執行中的 Observable,寫對它需要對 RxJS 有相當的理解。在 React,Promise 本身就能被很多人等待,只要把它存在一個變數裡就好。
之前替 Promise 列了一堆限制:不能取消、只有一個值、沒有運算子。今天它用最樸素的特性解決了一個問題:一張收據,可以給很多人看。